Prevent MessageList item jump during IME animation#6539
Conversation
Co-Authored-By: Claude <noreply@anthropic.com>
PR checklist ✅All required conditions are satisfied:
🎉 Great job! This PR is ready for review. |
SDK Size Comparison 📏
|
|
WalkthroughAdds a new ChangesIME-aware Message List Item Animation
Estimated code review effort: 2 (Simple) | ~10 minutes Possibly related PRs
Suggested labels: Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
`@stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/util/IsImeAnimating.kt`:
- Around line 35-42: Hoist the IME animation read out of isImeAnimating() usage
in messageListItemModifier so individual message items do not subscribe to
WindowInsets.imeAnimationSource and WindowInsets.imeAnimationTarget during
composition. Move the isImeAnimating() call to a shared ancestor composable,
cache the boolean there, and pass it down to the message item modifier or
related UI path so only one composable tracks the inset animation state.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: c9eae097-c7ca-4b35-af98-53513d826f51
📒 Files selected for processing (2)
stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.ktstream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/util/IsImeAnimating.kt
| @Composable | ||
| @OptIn(ExperimentalLayoutApi::class) | ||
| internal fun isImeAnimating(): Boolean { | ||
| val density = LocalDensity.current | ||
| val source = WindowInsets.imeAnimationSource.getBottom(density) | ||
| val target = WindowInsets.imeAnimationTarget.getBottom(density) | ||
| return source != target | ||
| } |
There was a problem hiding this comment.
🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
set -euo pipefail
# Locate relevant files and usages
git ls-files | rg 'stream-chat-android-compose/.*/IsImeAnimating\.kt|stream-chat-android-compose/.*/Messages|stream-chat-android-compose/.*/ChatComponentFactory|stream-chat-android-compose/.*/MessageList|stream-chat-android-compose/.*/ChannelList'
printf '\n--- isImeAnimating usages ---\n'
rg -n "isImeAnimating\(" stream-chat-android-compose || true
printf '\n--- imeAnimationSource/Target usages ---\n'
rg -n "imeAnimation(Source|Target)" stream-chat-android-compose || true
printf '\n--- target file ---\n'
nl -ba stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/util/IsImeAnimating.kt | sed -n '1,200p'Repository: GetStream/stream-chat-android
Length of output: 4619
🏁 Script executed:
set -euo pipefail
# Map the target file and likely caller sites
git ls-files | rg 'stream-chat-android-compose/.*/IsImeAnimating\.kt|stream-chat-android-compose/.*/Messages|stream-chat-android-compose/.*/ChatComponentFactory|stream-chat-android-compose/.*/MessageList|stream-chat-android-compose/.*/ChannelList'
printf '\n--- isImeAnimating usages ---\n'
rg -n "isImeAnimating\(" stream-chat-android-compose || true
printf '\n--- imeAnimationSource/Target usages ---\n'
rg -n "imeAnimation(Source|Target)" stream-chat-android-compose || true
printf '\n--- target file ---\n'
nl -ba stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/util/IsImeAnimating.kt | sed -n '1,200p'Repository: GetStream/stream-chat-android
Length of output: 4619
🏁 Script executed:
set -euo pipefail
git ls-files | rg 'stream-chat-android-compose/.*/IsImeAnimating\.kt|stream-chat-android-compose/.*/Messages|stream-chat-android-compose/.*/ChatComponentFactory|stream-chat-android-compose/.*/MessageList|stream-chat-android-compose/.*/ChannelList'
printf '\n--- isImeAnimating usages ---\n'
rg -n "isImeAnimating\(" stream-chat-android-compose || true
printf '\n--- imeAnimationSource/Target usages ---\n'
rg -n "imeAnimation(Source|Target)" stream-chat-android-compose || true
printf '\n--- target file ---\n'
nl -ba stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/util/IsImeAnimating.kt | sed -n '1,200p'Repository: GetStream/stream-chat-android
Length of output: 4619
🌐 Web query:
Jetpack Compose WindowInsets documentation should not be read during composition avoid doing composition for every frame of an animation
💡 Result:
In Jetpack Compose, WindowInsets values should not be read directly during the composition phase [1][2]. Doing so causes the composition to re-execute whenever the insets change, which—during an animation—results in triggering composition for every single frame [1][2]. This can lead to significant performance degradation and potential UI lag [1][2][3]. To avoid this, you should use built-in Compose modifiers and APIs designed to handle insets efficiently without triggering unnecessary recomposition [1][2]. Recommended practices include: Use specialized modifiers: Utilize modifiers such as Modifier.windowInsetsPadding, Modifier.systemBarsPadding, Modifier.imePadding, or Modifier.windowInsetsTopHeight [1][2]. These modifiers are designed to apply inset changes during the layout phase rather than the composition phase, ensuring performance efficiency even during animations [1][2]. Avoid reading values in composition: Do not access properties like WindowInsets.getTop(density) or WindowInsets.getBottom(density) within your Composable functions if those values change frequently [1][2]. If you have complex requirements that necessitate manual handling, look into APIs like onConsumedWindowInsetsChanged or MutableWindowInsets, though these should be used with care to maintain proper performance patterns [4][5]. Generally, sticking to the provided layout modifiers is the most performant and recommended approach [1][2].
Citations:
- 1: https://developer.android.com/reference/kotlin/androidx/compose/foundation/layout/WindowInsets
- 2: https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/WindowInsets.kt?authuser=5&q=file:androidx%2Fcompose%2Ffoundation%2Flayout%2FWindowInsets.kt%20function:union
- 3: https://www.myandroidsolutions.com/2026/03/17/android-keyboard-animation-windowinsetsanimation/
- 4: https://developer.android.com/reference/kotlin/androidx/compose/foundation/layout/onConsumedWindowInsetsChanged.modifier
- 5: https://developer.android.com/reference/kotlin/androidx/compose/foundation/layout/MutableWindowInsets
🏁 Script executed:
set -uo pipefail
printf '%s\n' '--- files ---'
git ls-files | rg 'stream-chat-android-compose/.*/IsImeAnimating\.kt|stream-chat-android-compose/.*/Messages|stream-chat-android-compose/.*/ChatComponentFactory|stream-chat-android-compose/.*/MessageList|stream-chat-android-compose/.*/ChannelList' || true
printf '\n%s\n' '--- isImeAnimating usages ---'
rg -n "isImeAnimating\(" stream-chat-android-compose || true
printf '\n%s\n' '--- imeAnimationSource/Target usages ---'
rg -n "imeAnimation(Source|Target)" stream-chat-android-compose || true
printf '\n%s\n' '--- target file ---'
nl -ba stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/util/IsImeAnimating.kt | sed -n '1,200p'Repository: GetStream/stream-chat-android
Length of output: 4633
🏁 Script executed:
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
paths = [
Path("stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/util/IsImeAnimating.kt"),
Path("stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt"),
]
for path in paths:
print(f"\n--- {path} ---")
text = path.read_text()
for i, line in enumerate(text.splitlines(), 1):
if path.name == "IsImeAnimating.kt" or (860 <= i <= 885):
print(f"{i:4d}: {line}")
PYRepository: GetStream/stream-chat-android
Length of output: 3631
🏁 Script executed:
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
path = Path("stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt")
text = path.read_text().splitlines()
for start in [840, 860, 868, 880]:
print(f"\n--- lines {start}-{start+25} ---")
for i in range(start, min(start+25, len(text))+1):
print(f"{i:4d}: {text[i-1]}")
PYRepository: GetStream/stream-chat-android
Length of output: 5014
🏁 Script executed:
set -euo pipefail
rg -n "messageListItemModifier|animateItem\(placementSpec = null\)|isImeAnimating\(\)" stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/Messages.kt stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageList.ktRepository: GetStream/stream-chat-android
Length of output: 759
🏁 Script executed:
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
path = Path("stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/util/IsImeAnimating.kt")
for i, line in enumerate(path.read_text().splitlines(), 1):
print(f"{i:4d}: {line}")
PYRepository: GetStream/stream-chat-android
Length of output: 2196
🏁 Script executed:
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
path = Path("stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt")
lines = path.read_text().splitlines()
for i in range(850, 881):
print(f"{i:4d}: {lines[i-1]}")
PYRepository: GetStream/stream-chat-android
Length of output: 1549
🏁 Script executed:
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
files = [
"stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt",
"stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/util/IsImeAnimating.kt",
]
for file in files:
path = Path(file)
print(f"\n=== {file} ===")
lines = path.read_text().splitlines()
if file.endswith("ChatComponentFactory.kt"):
for start, end in [(820, 890), (1, 120)]:
print(f"\n--- lines {start}-{end} ---")
for i in range(start, min(end, len(lines)) + 1):
print(f"{i:4d}: {lines[i-1]}")
else:
print("\n--- full file ---")
for i, line in enumerate(lines, 1):
print(f"{i:4d}: {line}")
PYRepository: GetStream/stream-chat-android
Length of output: 14306
Hoist IME animation state out of messageListItemModifier
isImeAnimating() reads WindowInsets.imeAnimationSource/imeAnimationTarget during composition, so each visible message item subscribes to IME inset updates and can recompose every frame while the keyboard animates. Move this check to a shared ancestor (or pass a cached boolean down) to avoid repeating the read per item.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/util/IsImeAnimating.kt`
around lines 35 - 42, Hoist the IME animation read out of isImeAnimating() usage
in messageListItemModifier so individual message items do not subscribe to
WindowInsets.imeAnimationSource and WindowInsets.imeAnimationTarget during
composition. Move the isImeAnimating() call to a shared ancestor composable,
cache the boolean there, and pass it down to the message item modifier or
related UI path so only one composable tracks the inset animation state.
andremion
left a comment
There was a problem hiding this comment.
LGTM. One optional note below.
| */ | ||
| @Composable | ||
| @OptIn(ExperimentalLayoutApi::class) | ||
| internal fun isImeAnimating(): Boolean { |
There was a problem hiding this comment.
The neighbouring isKeyboardVisibleAsState() avoids WindowInsets.ime because it can report wrong values on some OEM ROMs (Xiaomi/MIUI), see its KDoc. This helper reads imeAnimationSource / imeAnimationTarget from the same IME inset channel, so on those ROMs (or when the host window isn't set up for edge-to-edge inset dispatch) it may never report source != target and the fix just won't apply. It degrades gracefully, so not blocking. Worth a one-line note in the KDoc to document the dependency?


Goal
Port of v6 commit
13a8be56c7— PreventMessageListitem jump during IME animation (#6535) — todevelop.Fixes AND-1285. When opening the on-screen keyboard on
MessagesScreenwith only a few messages (items fit in the viewport), the ComposeMessageListitems visibly "hop" as the keyboard animates in — and again, more subtly, when it animates out.Root cause:
Modifier.animateItem()animates item placement changes. WithreverseLayout = true+Arrangement.Top, items shift position slightly on every frame while the LazyColumn viewport shrinks/grows in step with the IME animation; the placement spring lays its own animation on top of those per-frame deltas, which surfaces as the visible hop.Implementation
Suppress only the placement portion of
Modifier.animateItem()during the IME animation window; fade in/out and normal list-change placement animations (insertions, deletions, height changes) are untouched.isImeAnimating()@Composableinutil/IsImeAnimating.kt. It readsWindowInsets.imeAnimationSource/imeAnimationTarget(@ExperimentalLayoutApi), which diverge for the full IME animation and converge once it settles — a synchronous "is animating" boolean that flips on the same frame the animation starts (a coroutine-debounced variant lagged by one frame at the close-start edge).ChatComponentFactory.messageListItemModifier(params)now returnsModifier.animateItem(placementSpec = null)whileisImeAnimating()istrue, the originalModifier.animateItem()otherwise, and plainModifierunderLocalInspectionMode.current(Paparazzi).Port note: This was a manual port, not a clean cherry-pick. On
develop,messageListItemModifiertakes aMessageListItemModifierParamsargument that v6 lacks, so a rawgit cherry-pickconflicts on the method signature line. The fix body is identical to v6;IsImeAnimating.ktwas added verbatim.No public API change:
isImeAnimating()isinternaland themessageListItemModifier(params)signature is unchanged../gradlew :stream-chat-android-compose:apiDumpproduced no diff.UI Changes
No UI changes at rest. The visible difference is that message items no longer hop during keyboard open/close on channels where items fit in the viewport. See the before/after videos on the original v6 PR #6535.
Testing
./gradlew :stream-chat-android-compose:detekt— green./gradlew :stream-chat-android-compose:spotlessApply— no reformatting./gradlew :stream-chat-android-compose:apiDump— no API diff./gradlew :stream-chat-android-compose:testDebugUnitTest— green (includes Paparazzi snapshots, unchanged)Manual: open a channel with 2–5 messages via
MessagesScreen, tap the composer → keyboard opens smoothly with no item hop; tap outside → keyboard closes with no hop. Repeat on a channel with a full viewport (behaviour unchanged), and send a new message on a small channel (placement animation still works at rest).🤖 Generated with Claude Code
Summary by CodeRabbit